diff --git a/config.demo/src/database/seeds/PassportSeeder.php b/config.demo/src/database/seeds/PassportSeeder.php index 6fc68401..dcd22339 100644 --- a/config.demo/src/database/seeds/PassportSeeder.php +++ b/config.demo/src/database/seeds/PassportSeeder.php @@ -1,49 +1,64 @@ forceFill([ 'user_id' => null, 'name' => "Kolab Password Grant Client", 'secret' => \config('auth.proxy.client_secret'), 'provider' => 'users', 'redirect' => 'https://' . \config('app.website_domain'), 'personal_access_client' => 0, 'password_client' => 1, 'revoked' => false, ]); $client->id = \config('auth.proxy.client_id'); $client->save(); + // Create a client for Webmail SSO + $client = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => 'Webmail SSO client', + 'secret' => \config('auth.sso.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain') . '/roundcubemail/index.php/login/oauth', + 'personal_access_client' => 0, + 'password_client' => 0, + 'revoked' => false, + 'allowed_scopes' => ['email', 'auth.token'], + ]); + $client->id = \config('auth.sso.client_id'); + $client->save(); + // Create a client for synapse oauth $client = Passport::client()->forceFill([ 'user_id' => null, 'name' => "Synapse oauth client", 'secret' => \config('auth.synapse.client_secret'), 'provider' => 'users', 'redirect' => 'https://' . \config('app.website_domain') . "/_synapse/client/oidc/callback", 'personal_access_client' => 0, 'password_client' => 0, 'revoked' => false, 'allowed_scopes' => ['email'], ]); $client->id = \config('auth.synapse.client_id'); $client->save(); } } diff --git a/src/app/Auth/IdentityEntity.php b/src/app/Auth/IdentityEntity.php index 10f93b8c..13735ac8 100644 --- a/src/app/Auth/IdentityEntity.php +++ b/src/app/Auth/IdentityEntity.php @@ -1,42 +1,50 @@ identifier = $identifier; $this->user = User::findOrFail($identifier); } /** * When building the id_token, this entity's claims are collected */ public function getClaims(): array { // TODO: Other claims // TODO: Should we use this in AuthController::oauthUserInfo() for some de-duplicaton? - return [ + $claims = [ 'email' => $this->user->email, ]; + + // Short living password for IMAP/SMTP + // We use same TTL as for the OAuth tokens, so clients can get a new password on token refresh + // TODO: We should create the password only when the access token scope requests it + $ttl = config('auth.token_expiry_minutes') * 60; + $claims['auth.token'] = Utils::tokenCreate((string) $this->user->id, $ttl); + + return $claims; } } diff --git a/src/app/Auth/Utils.php b/src/app/Auth/Utils.php index ec89c46d..e56e006a 100644 --- a/src/app/Auth/Utils.php +++ b/src/app/Auth/Utils.php @@ -1,101 +1,102 @@ addSeconds(10)->format('YmdHis'); + $data = $userid . '!' . now()->addSeconds($ttl)->format('YmdHis'); $value = openssl_encrypt($data, $cipher, $key, 0, $iv, $tag); if ($value === false) { return null; } return trim(base64_encode($iv), '=') . '!' . trim(base64_encode($tag), '=') . '!' . trim(base64_encode($value), '='); } /** * Vaidate a simple authentication token * * @param string $token Token * * @return string|null User identifier, Null on failure */ public static function tokenValidate($token): ?string { if (!preg_match('|^[a-zA-Z0-9!+/]{50,}$|', $token)) { // this isn't a token, probably a normal password return null; } [$iv, $tag, $payload] = explode('!', $token); $iv = base64_decode($iv); $tag = base64_decode($tag); $payload = base64_decode($payload); $cipher = strtolower(config('app.cipher')); $key = config('app.key'); $decrypted = openssl_decrypt($payload, $cipher, $key, 0, $iv, $tag); if ($decrypted === false) { return null; } $payload = explode('!', $decrypted); if ( count($payload) != 2 || !preg_match('|^[0-9]+$|', $payload[0]) || !preg_match('|^[0-9]{14}+$|', $payload[1]) ) { // Invalid payload format return null; } // Check expiration date try { $expiry = Carbon::create( (int) substr($payload[1], 0, 4), (int) substr($payload[1], 4, 2), (int) substr($payload[1], 6, 2), (int) substr($payload[1], 8, 2), (int) substr($payload[1], 10, 2), (int) substr($payload[1], 12, 2) ); if (now() > $expiry) { return null; } } catch (\Exception $e) { return null; } return $payload[0]; } } diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index db17d128..f3149d91 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,279 +1,282 @@ user(); if (!empty(request()->input('refresh'))) { return $this->refreshAndRespond(request(), $user); } $response = V4\UsersController::userResponse($user); return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object * @param string $password Plain text password * @param string|null $secondFactor Second factor code if available */ public static function logonResponse(User $user, string $password, string $secondFactor = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'username' => $user->email, 'password' => $password, 'grant_type' => 'password', 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), 'scope' => 'api', 'secondfactor' => $secondFactor ]); $proxyRequest->headers->set('X-Client-IP', request()->ip()); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get an oauth token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { $v = Validator::make( $request->all(), [ 'email' => 'required|min:3', 'password' => 'required|min:1', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $user = \App\User::where('email', $request->email)->first(); if (!$user) { return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } return self::logonResponse($user, $request->password, $request->secondfactor); } /** * Approval request for the oauth authorization endpoint * * * The user is authenticated via the regular login page * * We assume implicit consent in the Authorization page * * Ultimately we return an authorization code to the caller via the redirect_uri * * The implementation is based on Laravel\Passport\Http\Controllers\AuthorizationController * * @param ServerRequestInterface $psrRequest PSR request * @param \Illuminate\Http\Request $request The API request * @param AuthorizationServer $server Authorization server * * @return \Illuminate\Http\JsonResponse */ public function oauthApprove(ServerRequestInterface $psrRequest, Request $request, AuthorizationServer $server) { if ($request->response_type != 'code') { return self::errorResponse(422, self::trans('validation.invalidvalueof', ['attribute' => 'response_type'])); } try { - // league/oauth2-server/src/Grant/ code expects GET parameters, but we're using POST here + // OpenID handler reads parameters from the request query string (GET) + $request->query->replace($request->input()); + + // OAuth2 server's code also expects GET parameters, but we're using POST here $psrRequest = $psrRequest->withQueryParams($request->input()); $authRequest = $server->validateAuthorizationRequest($psrRequest); $user = Auth::guard()->user(); // TODO I'm not sure if we should still execute this to deny the request $authRequest->setUser(new \Laravel\Passport\Bridge\User($user->getAuthIdentifier())); $authRequest->setAuthorizationApproved(true); // This will generate a 302 redirect to the redirect_uri with the generated authorization code $response = $server->completeAuthorizationRequest($authRequest, new Psr7Response()); } catch (\League\OAuth2\Server\Exception\OAuthServerException $e) { // Note: We don't want 401 or 400 codes here, use 422 which is used in our API $code = $e->getHttpStatusCode(); return self::errorResponse($code < 500 ? 422 : 500, $e->getMessage()); } catch (\Exception $e) { return self::errorResponse(422, self::trans('auth.error.invalidrequest')); } return response()->json([ 'status' => 'success', 'redirectUrl' => $response->getHeader('Location')[0], ]); } /** * Get the authenticated User information (using access token claims) * * @return \Illuminate\Http\JsonResponse */ public function oauthUserInfo() { $user = Auth::guard()->user(); $response = [ // Per OIDC spec. 'sub' must be always returned 'sub' => $user->id, ]; if ($user->tokenCan('email')) { $response['email'] = $user->email; $response['email_verified'] = $user->isActive(); # At least synapse depends on a "settings" structure being available $response['settings'] = [ 'name' => $user->name() ]; } // TODO: Other claims (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) // address: address // phone: phone_number and phone_number_verified // profile: name, family_name, given_name, middle_name, nickname, preferred_username, // profile, picture, website, gender, birthdate, zoneinfo, locale, and updated_at return response()->json($response); } /** * Get the user (geo) location * * @return \Illuminate\Http\JsonResponse */ public function location() { $ip = request()->ip(); $response = [ 'ipAddress' => $ip, 'countryCode' => \App\Utils::countryForIP($ip, ''), ]; return response()->json($response); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { $tokenId = Auth::user()->token()->id; $tokenRepository = app(TokenRepository::class); $refreshTokenRepository = app(RefreshTokenRepository::class); // Revoke an access token... $tokenRepository->revokeAccessToken($tokenId); // Revoke all of the token's refresh tokens... $refreshTokenRepository->revokeRefreshTokensByAccessTokenId($tokenId); return response()->json([ 'status' => 'success', 'message' => self::trans('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh(Request $request) { return self::refreshAndRespond($request); } /** * Refresh the token and respond with it. * * @param \Illuminate\Http\Request $request The API request. * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function refreshAndRespond(Request $request, $user = null) { $proxyRequest = Request::create('/oauth/token', 'POST', [ 'grant_type' => 'refresh_token', 'refresh_token' => $request->refresh_token, 'client_id' => \config('auth.proxy.client_id'), 'client_secret' => \config('auth.proxy.client_secret'), ]); $tokenResponse = app()->handle($proxyRequest); return self::respondWithToken($tokenResponse, $user); } /** * Get the token array structure. * * @param \Symfony\Component\HttpFoundation\Response $tokenResponse The response containing the token. * @param ?\App\User $user The user being authenticated * * @return \Illuminate\Http\JsonResponse */ protected static function respondWithToken($tokenResponse, $user = null) { $data = json_decode($tokenResponse->getContent()); if ($tokenResponse->getStatusCode() != 200) { if (isset($data->error) && $data->error == 'secondfactor' && isset($data->error_description)) { $errors = ['secondfactor' => $data->error_description]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } return response()->json(['status' => 'error', 'message' => self::trans('auth.failed')], 401); } if ($user) { $response = V4\UsersController::userResponse($user); } else { $response = []; } $response['status'] = 'success'; $response['access_token'] = $data->access_token; $response['refresh_token'] = $data->refresh_token; $response['token_type'] = 'bearer'; $response['expires_in'] = $data->expires_in; return response()->json($response); } } diff --git a/src/config/auth.php b/src/config/auth.php index 83c86ab0..e56123e1 100644 --- a/src/config/auth.php +++ b/src/config/auth.php @@ -1,145 +1,150 @@ [ 'guard' => 'api', 'passwords' => 'users', ], /* |-------------------------------------------------------------------------- | Authentication Guards |-------------------------------------------------------------------------- | | Next, you may define every authentication guard for your application. | Of course, a great default configuration has been defined for you | here which uses session storage and the Eloquent user provider. | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | Supported: "session" | */ 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'passport', 'provider' => 'users', ], ], /* |-------------------------------------------------------------------------- | User Providers |-------------------------------------------------------------------------- | | All authentication drivers have a user provider. This defines how the | users are actually retrieved out of your database or other storage | mechanisms used by this application to persist your user's data. | | If you have multiple user tables or models you may configure multiple | sources which represent each model / table. These sources may then | be assigned to any extra authentication guards you have defined. | | Supported: "database", "eloquent" | */ 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\User::class, ], // 'users' => [ // 'driver' => 'database', // 'table' => 'users', // ], ], /* |-------------------------------------------------------------------------- | Resetting Passwords |-------------------------------------------------------------------------- | | You may specify multiple password reset configurations if you have more | than one user table or model in the application and you want to have | separate password reset settings based on the specific user types. | | The expire time is the number of minutes that each reset token will be | considered valid. This security feature keeps tokens short-lived so | they have less time to be guessed. You may change this as needed. | | The throttle setting is the number of seconds a user must wait before | generating more password reset tokens. This prevents the user from | quickly generating a very large amount of password reset tokens. | */ 'passwords' => [ 'users' => [ 'provider' => 'users', 'table' => 'password_resets', 'expire' => 60, 'throttle' => 60, ], ], /* |-------------------------------------------------------------------------- | Password Confirmation Timeout |-------------------------------------------------------------------------- | | Here you may define the amount of seconds before a password confirmation | times out and the user is prompted to re-enter their password via the | confirmation screen. By default, the timeout lasts for three hours. */ 'password_timeout' => 10800, /* |-------------------------------------------------------------------------- | OAuth Proxy Authentication |-------------------------------------------------------------------------- | | If you are planning to use your application to self-authenticate as a | proxy, you can define the client and grant type to use here. This is | sometimes the case when a trusted Single Page Application doesn't | use a backend to send the authentication request, but instead | relies on the API to handle proxying the request to itself. | */ 'proxy' => [ 'client_id' => env('PASSPORT_PROXY_OAUTH_CLIENT_ID'), 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], 'synapse' => [ 'client_id' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_ID'), 'client_secret' => env('PASSPORT_SYNAPSE_OAUTH_CLIENT_SECRET'), ], + 'sso' => [ + 'client_id' => env('PASSPORT_WEBMAIL_SSO_CLIENT_ID'), + 'client_secret' => env('PASSPORT_WEBMAIL_SSO_CLIENT_SECRET'), + ], + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/config/openid.php b/src/config/openid.php index c7659342..f7967f92 100644 --- a/src/config/openid.php +++ b/src/config/openid.php @@ -1,85 +1,89 @@ [ /** * Place your Passport and OpenID Connect scopes here. * To receive an `id_token`, you should at least provide the openid scope. */ 'tokens_can' => [ 'openid' => 'Enable OpenID Connect', 'email' => 'Information about your email address', // 'profile' => 'Information about your profile', // 'phone' => 'Information about your phone numbers', // 'address' => 'Information about your address', // 'login' => 'See your login information', + 'auth.token' => 'Kolab authentication token', ], ], /** * Place your custom claim sets here. */ 'custom_claim_sets' => [ // 'login' => [ // 'last-login', // ], // 'company' => [ // 'company_name', // 'company_address', // 'company_phone', // 'company_email', // ], + 'auth.token' => [ + 'auth.token', + ] ], /** * You can override the repositories below. */ 'repositories' => [ // 'identity' => \OpenIDConnect\Repositories\IdentityRepository::class, 'identity' => \App\Auth\IdentityRepository::class, ], 'routes' => [ /** * When set to true, this package will expose the OpenID Connect Discovery endpoint. * - /.well-known/openid-configuration */ 'discovery' => true, /** * When set to true, this package will expose the JSON Web Key Set endpoint. */ 'jwks' => false, /** * Optional URL to change the JWKS path to align with your custom Passport routes. * Defaults to /oauth/jwks */ 'jwks_url' => '/oauth/jwks', ], /** * Settings for the discovery endpoint */ 'discovery' => [ /** * Hide scopes that aren't from the OpenID Core spec from the Discovery, * default = false (all scopes are listed) */ 'hide_scopes' => false, ], /** * The signer to be used */ 'signer' => \Lcobucci\JWT\Signer\Rsa\Sha256::class, /** * Optional associative array that will be used to set headers on the JWT */ 'token_headers' => [], /** * By default, microseconds are included. */ 'use_microseconds' => true, ]; diff --git a/src/resources/js/utils.js b/src/resources/js/utils.js index 9ca700c0..e88b4030 100644 --- a/src/resources/js/utils.js +++ b/src/resources/js/utils.js @@ -1,187 +1,189 @@ /** * Clear (bootstrap) form validation state */ const clearFormValidation = (form) => { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() } /** * File downloader */ const downloadFile = (url, filename) => { // TODO: This might not be a best way for big files as the content // will be stored (temporarily) in browser memory // TODO: This method does not show the download progress in the browser // but it could be implemented in the UI, axios has 'progress' property axios.get(url, { responseType: 'blob' }) .then(response => { const link = document.createElement('a') if (!filename) { const contentDisposition = response.headers['content-disposition'] filename = 'unknown' if (contentDisposition) { const match = contentDisposition.match(/filename="?(.+)"?/); if (match && match.length === 2) { filename = match[1]; } } } link.href = window.URL.createObjectURL(response.data) link.download = filename link.click() }) } /** * Create an object copy with specified properties only */ const pick = (obj, properties) => { let result = {} properties.forEach(prop => { if (prop in obj) { result[prop] = obj[prop] } }) return result } -const loader = '
Loading
' +const loader = '
Loading
' let isLoading = 0 /** * Display the 'loading...' element, lock the UI * * @param array|string|DOMElement|null|bool|jQuery $element Supported input: * - DOMElement or jQuery collection or selector string: for element-level loader inside * - array: for element-level loader inside the element specified in the first array element * - undefined, null or true: for page-level loader - * @param object $style Additional element style + * @param object $style Additional element style (and loader text) */ const startLoading = (element, style = null) => { let small = false if (Array.isArray(element)) { style = element[1] element = element[0] } if (element && element !== true) { // The loader inside some page element small = true if (style) { small = style.small delete style.small $(element).css(style) } else { $(element).css('position', 'relative') } } else { // The full page loader isLoading++ let loading = $('#app > .app-loader').removeClass('fadeOut') if (loading.length) { return } element = $('#app') } const loaderElement = $(loader) if (small) { loaderElement.addClass('small') } + loaderElement.find('.text').text(style && style.text ? style.text : '') + $(element).append(loaderElement) return loaderElement } /** * Hide the "loading" element * * @param array|string|DOMElement|null|bool|jQuery $element * @see startLoading() */ const stopLoading = (element) => { if (element && element !== true) { if (Array.isArray(element)) { element = element[0] } $(element).find('.app-loader').remove() } else if (isLoading > 0) { $('#app > .app-loader').addClass('fadeOut') isLoading--; } } let stripe = null const stripeInit = (callback) => { let script = $('#stripe-script') if (!script.length) { script = document.createElement('script') script.id = 'stripe-script' script.src = 'https://js.stripe.com/v3/' script.onload = () => { stripe = Stripe(window.config.stripePK) callback() } document.getElementsByTagName('head')[0].appendChild(script) } else { stripe = Stripe(window.config.stripePK) callback() } } /** * Executes payment checkout. * * @param object Vue component object * @param array Payment request parameters (Response from the payments API) * * @return bool Returns false if no supported checkout method is requested, True otherwise */ const paymentCheckout = (component, data) => { if (data.redirectUrl) { location.href = data.redirectUrl } else if (data.newWindowUrl) { window.open(data.newWindowUrl, '_blank') } else if (data.id) { stripeInit(() => { stripe.redirectToCheckout({ sessionId: data.id }).then(result => { // If it fails due to a browser or network error, // display the localized error message to the user if (result.error) { component.$toast.error(result.error.message) } }) }) } else { return false } return true } export { clearFormValidation, downloadFile, paymentCheckout, pick, startLoading, stopLoading } diff --git a/src/resources/lang/en/auth.php b/src/resources/lang/en/auth.php index 740ebee0..a1a08359 100644 --- a/src/resources/lang/en/auth.php +++ b/src/resources/lang/en/auth.php @@ -1,28 +1,28 @@ 'Invalid username or password.', 'password' => 'The provided password is incorrect.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'logoutsuccess' => 'Successfully logged out.', 'error.password' => "Invalid password", 'error.invalidrequest' => "Invalid authorization request.", 'error.geolocation' => "Country code mismatch", - 'error.nofound' => "User not found", + 'error.notfound' => "User not found", 'error.2fa' => "Second factor failure", 'error.2fa-generic' => "Second factor failure", ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 16c48900..bb905f16 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,585 +1,586 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'confirm' => "Confirm", 'continue' => "Continue", 'copy' => "Copy", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'resync' => "Resync", 'save' => "Save", 'search' => "Search", 'share' => "Share", 'signup' => "Sign Up", 'submit' => "Submit", 'subscribe' => "Subscribe", 'suspend' => "Suspend", 'tryagain' => "Try again", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'collection' => [ 'create' => "Create collection", 'new' => "New Collection", 'name' => "Name", ], 'companion' => [ 'title' => "Companion Apps", 'companion' => "Companion App", 'name' => "Name", 'create' => "Pair new device", 'create-recovery-device' => "Prepare recovery code", 'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.", 'download-description' => "You may download the Companion App for Android here: " . "Download", 'description-detailed' => "Here is how this works: " . "Pairing a device will automatically enable multi-factor autentication for all login attempts. " . "This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " . "Any authentication attempt will result in a notification on your device, " . "that you can use to confirm if it was you, or deny otherwise. " . "Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " . "Unpair all your active devices to disable multi-factor authentication again.", 'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " . "will permanently lock you out of your account with no course for recovery. " . "Always make sure you have a recovery QR-Code printed to pair a recovery device.", 'new' => "Pair new device", 'recovery' => "Prepare recovery device", 'paired' => "Paired devices", 'print' => "Print for backup", 'pairing-instructions' => "Pair your device using the following QR-Code.", 'recovery-device' => "Recovery Device", 'new-device' => "New Device", 'deviceid' => "Device ID", 'list-empty' => "There are currently no devices", 'delete' => "Delete/Unpair", 'delete-companion' => "Delete/Unpair", 'delete-text' => "You are about to delete this entry and unpair any paired companion app. " . "This cannot be undone, but you can pair the device again.", 'pairing-successful' => "Your companion app is paired and ready to be used " . "as a multi-factor authentication device.", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'companion' => "Companion app", 'domains' => "Domains", 'files' => "Files", 'invitations' => "Invitations", 'myaccount' => "My account", 'policies' => "Policies", 'profile' => "Your profile", 'resources' => "Resources", 'shared-folders' => "Shared folders", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'name' => "Name", 'new' => "New distribution list", 'recipients' => "Recipients", 'sender-policy' => "Sender Access List", 'sender-policy-text' => "With this list you can specify who can send mail to the distribution list." . " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to." . " If the list is empty, mail from anyone is allowed.", ], 'domain' => [ 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", 'confirm' => "Domain ownership confirmation", 'confirm-intro' => "In order to confirm that you're the actual owner or administrator of the domain, " . "we need to run a confirmation process before finally activating it for email delivery.", 'confirm-dns' => "The domain must have one of the following entries in DNS:", 'confirm-dns-txt' => "TXT entry with value:", 'confirm-dns-cname' => "or CNAME entry:", 'confirm-outro' => "Please add one of those records to the DNS of your domain via your domain name provider. " . "When this is done press the button below to start the confirmation.", 'confirm-sample' => "Here's a sample zone file for your domain:", 'create' => "Create domain", 'delete' => "Delete domain", 'delete-domain' => "Delete {domain}", 'delete-text' => "Do you really want to delete this domain permanently?" . " This is only possible if there are no users, aliases or other objects in this domain." . " Please note that this action cannot be undone.", 'dns-confirm' => "Domain DNS confirmation sample:", 'dns-config' => "Domain DNS configuration sample:", 'list-empty' => "There are no domains in this account.", 'namespace' => "Namespace", 'new' => "New domain", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", 'form' => "Form validation error", ], 'file' => [ 'create' => "Create file", 'delete' => "Delete file", 'drop' => "Click or drop file(s) here", 'list-empty' => "There are no files in this account.", 'mimetype' => "Mimetype", 'mtime' => "Modified", 'new' => "New file", 'search' => "File name", 'sharing' => "Sharing", 'sharing-links-text' => "You can share the file with other users by giving them read-only access " . "to the file via a unique link.", ], 'form' => [ 'acl' => "Access rights", 'acl-full' => "All", 'acl-read-only' => "Read-only", 'acl-read-write' => "Read-write", 'amount' => "Amount", 'anyone' => "Anyone", 'code' => "Confirmation Code", 'config' => "Configuration", 'comment' => "Comment", 'companion' => "Companion App", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'emails' => "Email Addresses", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'geolocation' => "Your current location: {location}", 'lastname' => "Last Name", 'less' => "Less", 'name' => "Name", 'months' => "months", 'more' => "More", 'none' => "none", 'norestrictions' => "No restrictions", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'personal' => "Personal information", 'phone' => "Phone", 'selectcountries' => "Select countries", 'settings' => "Settings", 'shared-folder' => "Shared Folder", 'size' => "Size", 'status' => "Status", 'subscriptions' => "Subscriptions", 'surname' => "Surname", 'type' => "Type", 'unknown' => "unknown", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'list-empty' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'log' => [ 'event' => "Event", 'list-none' => "There's no events in the log", 'history' => "History", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'signing_in' => "Signing in...", 'webmail' => "Webmail" ], 'meet' => [ // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", + 'redirecting' => "Redirecting...", 'uploading' => "Uploading...", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'link-invalid' => "The password reset code is expired or invalid.", 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'policies' => [ 'password-policy' => "Password Policy", 'password-retention' => "Password Retention", 'password-max-age' => "Require a password change every", ], 'resource' => [ 'create' => "Create resource", 'delete' => "Delete resource", 'invitation-policy' => "Invitation policy", 'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically" . " if there is no conflicting event on the requested time slot. Invitation policy allows" . " for rejecting such requests or to require a manual acceptance from a specified user.", 'ipolicy-manual' => "Manual (tentative)", 'ipolicy-accept' => "Accept", 'ipolicy-reject' => "Reject", 'list-title' => "Resource | Resources", 'list-empty' => "There are no resources in this account.", 'new' => "New resource", ], 'room' => [ 'create' => "Create room", 'delete' => "Delete room", 'copy-location' => "Copy room location", 'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.", 'goto' => "Enter the room", 'list-empty' => "There are no conference rooms in this account.", 'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.", 'list-title' => "Voice & video conferencing rooms", 'moderators' => "Moderators", 'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.", 'new' => "New room", 'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.", 'title' => "Room: {name}", 'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.", ], 'shf' => [ 'aliases-none' => "This shared folder has no email aliases.", 'create' => "Create folder", 'delete' => "Delete folder", 'acl-text' => "Defines user permissions to access the shared folder.", 'list-title' => "Shared folder | Shared folders", 'list-empty' => "There are no shared folders in this account.", 'new' => "New shared folder", 'type-mail' => "Mail", 'type-event' => "Calendar", 'type-contact' => "Address Book", 'type-task' => "Tasks", 'type-note' => "Notes", 'type-file' => "Files", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your {app} identity (you can choose additional addresses later).", 'created' => "The account is about to be created!", 'token' => "Signup authorization token", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-resource' => "We are preparing the resource.", 'prepare-shared-folder' => "We are preparing the shared folder.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-resource' => "The resource is almost ready.", 'ready-shared-folder' => "The shared-folder is almost ready.", 'ready-user' => "The user account is almost ready.", 'confirm' => "Confirm your domain to finish the setup process.", 'confirm-domain' => "Confirm domain", 'degraded' => "Degraded", 'deleted' => "Deleted", 'restricted' => "Restricted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or the affected email address", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'add-beta' => "Enable beta program", 'address' => "Address", 'aliases' => "Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'degraded-warning' => "The account is degraded. Some features have been disabled.", 'degraded-hint' => "Please, make a payment.", 'delete' => "Delete user", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'domains' => "Domains", 'ext-email' => "External Email", 'email-aliases' => "Email Aliases", 'finances' => "Finances", 'geolimit' => "Geo-lockin", 'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'imapproxy' => "IMAP proxy", 'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.", 'list-title' => "User accounts", 'list-empty' => "There are no users in this account.", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'pass-input' => "Enter password", 'pass-link' => "Set via link", 'pass-link-label' => "Link:", 'pass-link-hint' => "Press Submit to activate the link", 'passwordpolicy' => "Password Policy", 'price' => "Price", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'resources' => "Resources", 'title' => "User account", 'search' => "User email address or name", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'locked-text' => "The account is locked until you set up auto-payment successfully.", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'norefund' => "The money in your wallet is non-refundable.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index a1cdf551..b6527ea3 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,605 +1,613 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; + flex-direction: column; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } + .text { + width: 100%; + text-align: center; + color: #b2aa99; + margin-top: 1em; + } + &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { td { background-color: #f8f8f8; color: grey; text-align: center; vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } td .btn-link { vertical-align: initial; } td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, td.price, th.size, td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } &.files { table-layout: fixed; td { white-space: nowrap; } td.name { overflow: hidden; text-overflow: ellipsis; } /* td.size, th.size { width: 80px; } td.mtime, th.mtime { width: 140px; @include media-breakpoint-down(sm) { display: none; } } */ td.buttons, th.buttons { width: 50px; } } &.eventlog { .details, .btn-less { display: none; } tr.open { .btn-more { display: none; } .details { display: block; } .btn-less { display: initial; } } td.description { width: 98%; } } } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: $main-color; border: 3px solid $main-color; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } .modal { .modal-dialog, .modal-content { max-height: calc(100vh - 3.5rem); } .modal-body { overflow: auto !important; } &.fullscreen { .modal-dialog { height: 100%; width: 100%; max-width: calc(100vw - 1rem); } .modal-content { height: 100%; max-height: 100% !important; } .modal-body { padding: 0; margin: 1em; overflow: hidden !important; } } } .credit-cards { img { width: 4em; height: 2.8em; padding: 0.4em; border: 1px solid lightgrey; border-radius: 0.4em; margin-right: 0.5em; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; color: $link-color; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-companionapp, &.link-domains, &.link-policies, &.link-resources, &.link-wallet, &.link-invitations { svg { transform: scale(0.8); } } &.link-distlists, &.link-files, &.link-settings, &.link-shared-folders { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #payment-method-selection { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; color: $link-color; } svg { width: 6rem; height: 6rem; margin: auto; } .link-banktransfer svg { transform: scale(.8); } } #summary-summary { padding: 0.5rem; table { width: 100%; } tr { &.total { font-weight: bold; } &.vat-summary { font-size: small; } } td { padding: 0.25em; &.money { white-space: nowrap; text-align: right; } } } #logon-form { flex-basis: auto; /* Bootstrap issue? See logon page with width < 992 */ } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } .tab-pane > .card-body { padding: 1rem; } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/vue/Authorize.vue b/src/resources/vue/Authorize.vue index d17eed8f..da3181cd 100644 --- a/src/resources/vue/Authorize.vue +++ b/src/resources/vue/Authorize.vue @@ -1,31 +1,33 @@ diff --git a/src/tests/Feature/Controller/AuthTest.php b/src/tests/Feature/Controller/AuthTest.php index 075b274f..9915f03b 100644 --- a/src/tests/Feature/Controller/AuthTest.php +++ b/src/tests/Feature/Controller/AuthTest.php @@ -1,584 +1,586 @@ app['auth']->forgetGuards(); } /** * {@inheritDoc} */ public function setUp(): void { parent::setUp(); $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); $this->expectedExpiry = \config('auth.token_expiry_minutes') * 60; \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('UsersControllerTest1@userscontroller.com'); $this->deleteTestDomain('userscontroller.com'); \App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete(); $user = $this->getTestUser('john@kolab.org'); $user->setSetting('limit_geo', null); parent::tearDown(); } /** * Test fetching current user info (/api/auth/info) */ public function testInfo(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com', ['status' => User::STATUS_NEW]); $domain = $this->getTestDomain('userscontroller.com', [ 'status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_PUBLIC, ]); $response = $this->get("api/auth/info"); $response->assertStatus(401); $response = $this->actingAs($user)->get("api/auth/info"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertEquals(User::STATUS_NEW, $json['status']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!isset($json['access_token'])); // Note: Details of the content are tested in testUserResponse() // Test token refresh via the info request // First we log in to get the refresh token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $user = $this->getTestUser('john@kolab.org'); $response = $this->post("api/auth/login", $post); $json = $response->json(); $response = $this->actingAs($user) ->post("api/auth/info?refresh=1", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('john@kolab.org', $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['expires_in'])); } /** * Test fetching current user location (/api/auth/location) */ public function testLocation(): void { $user = $this->getTestUser('UsersControllerTest1@userscontroller.com'); // Authentication required $response = $this->get("api/auth/location"); $response->assertStatus(401); $headers = ['X-Client-IP' => '127.0.0.2']; $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('', $json['countryCode']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->actingAs($user)->withHeaders($headers)->get("api/auth/location"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('127.0.0.2', $json['ipAddress']); $this->assertSame('US', $json['countryCode']); } /** * Test /api/auth/login */ public function testLogin(): string { $user = $this->getTestUser('john@kolab.org'); // Request with no data $response = $this->post("api/auth/login", []); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertArrayHasKey('email', $json['errors']); $this->assertArrayHasKey('password', $json['errors']); // Request with invalid password $post = ['email' => 'john@kolab.org', 'password' => 'wrong']; $response = $this->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid username or password.', $json['message']); // Valid user+password $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $this->assertEquals($user->id, $json['id']); $this->assertEquals($user->email, $json['email']); $this->assertTrue(is_array($json['statusInfo'])); $this->assertTrue(is_array($json['settings'])); // Valid user+password (upper-case) $post = ['email' => 'John@Kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $json = $response->json(); $response->assertStatus(200); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); // TODO: We have browser tests for 2FA but we should probably also test it here return $json['access_token']; } /** * Test /api/auth/login with geo-lockin */ public function testLoginGeoLock(): void { $user = $this->getTestUser('john@kolab.org'); $user->setConfig(['limit_geo' => ['US']]); $headers['X-Client-IP'] = '127.0.0.2'; $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame("Invalid username or password.", $json['message']); $this->assertSame('error', $json['status']); \App\IP4Net::create([ 'net_number' => '127.0.0.0', 'net_broadcast' => '127.255.255.255', 'net_mask' => 8, 'country' => 'US', 'rir_name' => 'test', 'serial' => 1, ]); $response = $this->withHeaders($headers)->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertEquals($user->id, $json['id']); } /** * Test /api/auth/logout * * @depends testLogin */ public function testLogout($token): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/logout"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/logout", []); $response->assertStatus(401); // Request with invalid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . "foobar"])->post("api/auth/logout"); $response->assertStatus(401); // Request with valid token $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->post("api/auth/logout"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals('success', $json['status']); $this->assertEquals('Successfully logged out.', $json['message']); $this->resetAuth(); // Check if it really destroyed the token? $response = $this->withHeaders(['Authorization' => 'Bearer ' . $token])->get("api/auth/info"); $response->assertStatus(401); } /** * Test /api/auth/refresh */ public function testRefresh(): void { // Request with no token, testing that it requires auth $response = $this->post("api/auth/refresh"); $response->assertStatus(401); // Test the same using JSON mode $response = $this->json('POST', "api/auth/refresh", []); $response->assertStatus(401); // Login the user to get a valid token $post = ['email' => 'john@kolab.org', 'password' => 'simple123']; $response = $this->post("api/auth/login", $post); $response->assertStatus(200); $json = $response->json(); $token = $json['access_token']; $user = $this->getTestUser('john@kolab.org'); // Request with a valid token $response = $this->actingAs($user)->post("api/auth/refresh", ['refresh_token' => $json['refresh_token']]); $response->assertStatus(200); $json = $response->json(); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue($json['access_token'] != $token); $this->assertTrue( ($this->expectedExpiry - 5) < $json['expires_in'] && $json['expires_in'] < ($this->expectedExpiry + 5) ); $this->assertEquals('bearer', $json['token_type']); $new_token = $json['access_token']; // TODO: Shall we invalidate the old token? // And if the new token is working $response = $this->withHeaders(['Authorization' => 'Bearer ' . $new_token])->get("api/auth/info"); $response->assertStatus(200); } /** * Test OAuth2 Authorization Code Flow */ public function testOAuthAuthorizationCodeFlow(): void { $user = $this->getTestUser('john@kolab.org'); // Request unauthenticated, testing that it requires auth $response = $this->post("api/oauth/approve"); $response->assertStatus(401); // Request authenticated, invalid POST data $post = ['response_type' => 'unknown']; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Invalid value of request property: response_type.', $json['message']); // Request authenticated, invalid POST data $post = [ 'client_id' => 'unknown', 'response_type' => 'code', 'scope' => 'email', // space-separated 'state' => 'state', // optional 'nonce' => 'nonce', // optional ]; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('Client authentication failed', $json['message']); $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id')); $post['client_id'] = $client->id; // Request authenticated, invalid scope $post['scope'] = 'unknown'; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertSame('The requested scope is invalid, unknown, or malformed', $json['message']); // Request authenticated, valid POST data $post['scope'] = 'email'; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(200); $json = $response->json(); $url = $json['redirectUrl']; parse_str(parse_url($url, \PHP_URL_QUERY), $params); $this->assertTrue(str_starts_with($url, $client->redirect . '?')); $this->assertCount(2, $params); $this->assertSame('state', $params['state']); $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']); $this->assertSame('success', $json['status']); // Note: We do not validate the code trusting Passport to do the right thing. Should we not? // Token endpoint tests // Valid authorization code, but invalid secret $post = [ 'grant_type' => 'authorization_code', 'client_id' => $client->id, 'client_secret' => 'invalid', // 'redirect_uri' => '', 'code' => $params['code'], ]; // Note: This is a 'web' route, not 'api' $this->resetAuth(); // reset guards $response = $this->post("/oauth/token", $post); $response->assertStatus(401); $json = $response->json(); $this->assertSame('invalid_client', $json['error']); $this->assertTrue(!empty($json['error_description'])); // Valid authorization code $post['client_secret'] = \config('auth.synapse.client_secret'); $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $params = $response->json(); $this->assertSame('Bearer', $params['token_type']); $this->assertTrue(!empty($params['access_token'])); $this->assertTrue(!empty($params['refresh_token'])); $this->assertTrue(!empty($params['expires_in'])); $this->assertTrue(empty($params['id_token'])); // Invalid authorization code // Note: The code is being revoked on use, so we expect it does not work anymore $response = $this->post("/oauth/token", $post); $response->assertStatus(400); $json = $response->json(); $this->assertSame('invalid_request', $json['error']); $this->assertTrue(!empty($json['error_description'])); // Token refresh unset($post['code']); $post['grant_type'] = 'refresh_token'; $post['refresh_token'] = $params['refresh_token']; $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Bearer', $json['token_type']); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['refresh_token'])); $this->assertTrue(!empty($json['expires_in'])); $this->assertTrue(empty($json['id_token'])); $this->assertNotEquals($json['access_token'], $params['access_token']); $this->assertNotEquals($json['refresh_token'], $params['refresh_token']); $token = $json['access_token']; // Validate the access token works on /oauth/userinfo endpoint $this->resetAuth(); // reset guards $headers = ['Authorization' => 'Bearer ' . $token]; $response = $this->withHeaders($headers)->get("/oauth/userinfo"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['sub']); $this->assertEquals($user->email, $json['email']); // Validate that the access token does not give access to API other than /oauth/userinfo $this->resetAuth(); // reset guards $response = $this->withHeaders($headers)->get("/api/auth/location"); $response->assertStatus(403); } /** * Test OpenID-Connect Authorization Code Flow */ public function testOIDCAuthorizationCodeFlow(): void { $user = $this->getTestUser('john@kolab.org'); - $client = \App\Auth\PassportClient::find(\config('auth.synapse.client_id')); + $client = \App\Auth\PassportClient::find(\config('auth.sso.client_id')); // Note: Invalid input cases were tested above, we omit them here - // This is essentially the same as for OAuth2, but with extended scope + // This is essentially the same as for OAuth2, but with extended scopes $post = [ 'client_id' => $client->id, 'response_type' => 'code', - 'scope' => 'openid email', + 'scope' => 'openid email auth.token', 'state' => 'state', 'nonce' => 'nonce', ]; $response = $this->actingAs($user)->post("api/oauth/approve", $post); $response->assertStatus(200); $json = $response->json(); $url = $json['redirectUrl']; parse_str(parse_url($url, \PHP_URL_QUERY), $params); $this->assertTrue(str_starts_with($url, $client->redirect . '?')); $this->assertCount(2, $params); $this->assertSame('state', $params['state']); $this->assertMatchesRegularExpression('/^[a-f0-9]{50,}$/', $params['code']); $this->assertSame('success', $json['status']); // Token endpoint tests $post = [ 'grant_type' => 'authorization_code', 'client_id' => $client->id, 'client_secret' => \config('auth.synapse.client_secret'), 'code' => $params['code'], ]; $this->resetAuth(); // reset guards state $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $params = $response->json(); $this->assertSame('Bearer', $params['token_type']); $this->assertTrue(!empty($params['access_token'])); $this->assertTrue(!empty($params['refresh_token'])); $this->assertTrue(!empty($params['id_token'])); $this->assertTrue(!empty($params['expires_in'])); $token = $this->parseIdToken($params['id_token']); $this->assertSame('JWT', $token['typ']); $this->assertSame('RS256', $token['alg']); + $this->assertSame('nonce', $token['nonce']); $this->assertSame(url('/'), $token['iss']); $this->assertSame($user->email, $token['email']); + $this->assertSame((string) $user->id, \App\Auth\Utils::tokenValidate($token['auth.token'])); // TODO: Validate JWT token properly // Token refresh unset($post['code']); $post['grant_type'] = 'refresh_token'; $post['refresh_token'] = $params['refresh_token']; $response = $this->post("/oauth/token", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Bearer', $json['token_type']); $this->assertTrue(!empty($json['access_token'])); $this->assertTrue(!empty($json['refresh_token'])); $this->assertTrue(!empty($json['id_token'])); $this->assertTrue(!empty($json['expires_in'])); // Validate the access token works on /oauth/userinfo endpoint $this->resetAuth(); // reset guards state $headers = ['Authorization' => 'Bearer ' . $json['access_token']]; $response = $this->withHeaders($headers)->get("/oauth/userinfo"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals($user->id, $json['sub']); $this->assertEquals($user->email, $json['email']); // Validate that the access token does not give access to API other than /oauth/userinfo $this->resetAuth(); // reset guards state $response = $this->withHeaders($headers)->get("/api/auth/location"); $response->assertStatus(403); } /** * Test to make sure Passport routes are disabled */ public function testPassportDisabledRoutes(): void { $this->post("/oauth/authorize", [])->assertStatus(405); $this->post("/oauth/token/refresh", [])->assertStatus(405); } /** * Parse JWT token into an array */ private function parseIdToken($token): array { [$headb64, $bodyb64, $cryptob64] = explode('.', $token); $header = json_decode(base64_decode(strtr($headb64, '-_', '+/'), true), true); $body = json_decode(base64_decode(strtr($bodyb64, '-_', '+/'), true), true); return array_merge($header, $body); } } diff --git a/src/tests/Feature/Controller/WellKnownTest.php b/src/tests/Feature/Controller/WellKnownTest.php index 801b80c4..5cf7fa93 100644 --- a/src/tests/Feature/Controller/WellKnownTest.php +++ b/src/tests/Feature/Controller/WellKnownTest.php @@ -1,54 +1,54 @@ get('.well-known/openid-configuration'); $response->assertStatus(200) ->assertJson([ 'issuer' => $href, 'authorization_endpoint' => $href . '/oauth/authorize', 'token_endpoint' => $href . '/oauth/token', 'userinfo_endpoint' => $href . '/oauth/userinfo', 'grant_types_supported' => [ 'authorization_code', 'client_credentials', 'refresh_token', 'password', ], 'response_types_supported' => [ 'code' ], 'id_token_signing_alg_values_supported' => [ 'RS256' ], 'scopes_supported' => [ 'openid', 'email', ], ]); } /** * Test ./well-known/mta-sts.txt */ public function testMtaSts(): void { $domain = \config('app.domain'); $response = $this->get('.well-known/mta-sts.txt'); $response->assertStatus(200) ->assertHeader('Content-Type', 'text/plain; charset=UTF-8') ->assertContent("version: STSv1\nmode: enforce\nmx: {$domain}\nmax_age: 604800"); } }